2.3-模块化开发
Create by fall on 11 Oct 2020 Recently revised in 21 Dec 2023
模块化开发
单一文件开发(所有脚本放在一个文件内)缺点很多:函数命名冲突、维护起来特别繁琐、上下文查找困难。
所谓的模块化开发就是将巨大的大一文件,进行模块化的拆分,通过拆分来减少单个文件带来的缺点,使代码更容易维护。
分割文件:通过单独地将一个功能封装到模块(文件)中,模块之间相互隔离,通过特定的接口公开内部成员,或者引入别的模块。这样开发可以方便代码重用,提升开发效率,并且方便后期维护。
规范种类:
ECMA 提出的官方规范:ESM
在官方规范提出之前,社区曾提出过自己的实现规范,并不是通用的模块化规范,这些规范有:
- 应用于服务器的规范:CommonJS 规范
- 应用于浏览器端的规范:AMD 规范,CMD 规范
- 通用的规范:UMD 规范
加载模式
浏览器传统加载模式
<!-- 同步加载 -->
<script src="test.js"></script>
<!-- 同步加载,顺序执行,文档解析完成后执行 -->
<script src="test.js" defer></script>
<!-- 异步加载,只要下载完就执行-->
<script src="test.js" async></script>
浏览器 <script>
标签中设置 type = "module"
将默认按照 defer 进行加载
<!-- ES6 的模块依赖于 import export 两个命令,并且自动采用严格模式 -->
<script type="module" src="test.js"></script>
同步加载
<script src="script.js"></script>
没有 defer 或 async,浏览器会立即加载并执行指定的脚本,读到脚本就加载并执行(中断浏览器渲染)。
异步加载:
<script async src="script.js"></script>
async
表示异步执行 JavaScript,无论此刻是 HTML 解析阶段还是 DOMContentLoaded 触发之后,如果已经加载好,就会开始执行。但是这种方式加载的 JavaScript 依然会阻塞 load 事件。async-script 可能在 DOMContentLoaded 触发之前或之后执行,但一定在 load 触发之前执行。
延迟执行:
<script defer src="script.js"></script>
defer
属性表示延迟执行引入的 JavaScript,即这段 JavaScript 加载时 HTML 并未停止解析,这两个过程是并行的。整个 document 解析完毕且 defer-script 也加载完成之后,会执行所有由 defer-script 加载的 JavaScript 代码,然后触发 DOMContentLoaded 事件。
defer 与相比普通 script,有两点区别:载入 JavaScript 文件时不阻塞 HTML 的解析,执行阶段被放到 HTML 标签解析完成之后。在加载多个 JS 脚本的时候,async 是无顺序的加载,而 defer 是有顺序的加载。
ESM 规范
- 导入的变量名必须与导出模块的名字一致,可以使用
as
进行重命名; - 导入的变量都是只读的,不能改写;
import
命令具有提升效果,会提升到整个模块的头部,首先执行;import
是编译时导入,所以不能将其写到代码块(比如if
判断块里)或者函数内部;import
会执行所加载的模块的代码,如果重复导入同一个模块则只会执行一次模块;
导入导出
在规范中定义,每个 JS 文件都是一个独立的模块。
导入模块成员,使用 import 关键字。暴露模块成员使用 export 关键字。
// tool.js
export function workFlow() {
console.log('公司是我家')
}
export const lovelyNum = '996'
// main.js
import { lovelyNum , workFlow } from './tool.js'
workFlow()
命名导入导出
进行命名导入导出
// tool.js
function workFlow () {
console.log('公司是我家')
}
const lovelyNum = '996'
export {workFlow,favorite as lovelyNum} // 将 lovelyNum 命名为 favorite 进行导出
// main.js
import {lovelyNum as luckyNum} from './tool.js'
console.log(luckyNum) // '996'
命名空间导入
命名导入导出有时过于繁琐,此时会使用命名空间导入
// tool.js
export export function createID () {
const date = new Date()
return date.getTime
}
export lovelyNum = '996'
// main.js
import * as creature from './tool.js'
creature.lovelyNum // 996
默认导入组件
默认导入组件
export default
是默认导出的内容,使用时,直接导入即可。
// moudle.js
let cagg = 233
function app(){console.log(app)}
export default {
cagg,app
}
// index.js
import app from './moudle.js'
app.cagg
导入后直接执行
引入直接执行的文件
使用该方法引入的文件会直接执行
// run.js
console.log('Baidiaoo')
// index.js
import './run.js'
引入多个组件
引入多个组件
// moudle.js
export milk = '一天一瓶,也喝不穷'
let eggs = 233
function app(){console.log(app)}
export default {
eggs,app
}
// index.js
import app,{milk} from './moudle.js'
app.eggs
console.log(milk)
直接导出
直接导出
// test.js
export some = 'take some, or learn some.'
// utils.js
export {fake as some} from './test.js'
// index.js
import {fake} from './util.js'
// 转发默认接口
// person.js
const name = '布兰', age = 12
export default { name, age }
export { default } from './person.js'
// 相当于
import person from './person.js'
export default person
动态查找策略
当使用查找策略
// 比如说使用一下查找方案
import * as foo from 'foo'
// 系统会依次按照下面的目录查找
// ./node_modules/foo
// .././node_modules/foo
// ../.././node_modules/foo
// 会向上层文件夹的 node_modules,再到上层,直到系统的根目录
// 再深一层的引入目录也相同
// import * as foo from 'something/foo'
// ./node_modules/something/foo
// ../node_modules/something/foo
// ../../node_modules/something/foo
当引入 import * as foo from 'filePath'
这个 foo 具体代表什么呢?
会依次按照这些情况去分析 filePath
- 它是一个文件
- 否则的话,是文件夹,且文件夹存在
index
文件 - 否则的话,是文件夹,并且存在
package.json
,该文件中指定的type
文件存在 - 否则的话,是文件夹,并且存在
package.json
,在该文件中指定的main
这些文件的类型可能是:.ts
、.d.ts
、.js
meta
在使用 module 引入的包使用,包含这个模块的元数据信息。
console.log(import.meta)
// {url:'http://127.0.0.1:3002/src/main.ts'}
url 也可能包含参数或者哈希(比如后缀 ?
或 #
)
<script type="module">
import './index.mjs?someURLInfo=5';
</script>
new URL(import.meta.url).searchParams.get('someURLInfo')// 5
动态导入
import()
一个 promise,但是 import
本身是一个关键字,而不是一个方法。
import
declaration syntax (import something from "somewhere"
) is static and will always result in the imported module being evaluated at load time.
// 只引入这个包的副作用
(async () => {
if (somethingIsTrue) {
// import module for side effects
await import("/modules/my-module.js");
}
})();
// 引入包的默认名称
(async () => {
if (somethingIsTrue) {
const {
default: myDefault,
foo,
bar,
} = await import("/modules/my-module.js");
}
})();
顶层 await
const i18n = await import(`./content-${language}.mjs`);
// index.mjs
// to top-level await
await Promise.resolve('🍎') // '🍎'
CommonJS 规范
Node.js 所使用的规范,简称 CJS,如果不想深入理解,其实也可以忽略
node 版本在 14.10 以上,也可以在 package.json 中添加
type
属性为module
即可使用import
作为关键字进行引入的实现。
每一个文件就是一个模块,拥有自己独立的作用域,变量,以及方法等,对其他的模块都不可见。每个模块内部,module
变量代表当前模块。这个变量是一个对象,它的 exports
属性(即module.exports
)是对外的接口。
require()
方法用于加载模块,加载模块时,实际上加载的是 module.exports
属性。
模块分为两种,一是 Node 提供的模块,称为核心模块;二是用户编写的模块,成为文件模块。核心模块(HTTP 模块、URL 模块、FS 模块)在 Node 源代码的编译过程中,编译进了二进制执行文件,所以它的加载速度是最快的。文件模块是在运行时动态加载的,需要完整的路径分析、文件定位、编译执行过程等……所以它的速度相对核心模块来说会更慢一些。
与 ESM 的不同
ESM
的执行可以分为三个步骤:
-
构建: 确定从哪里下载该模块文件、下载并将所有的文件解析为模块记录
-
实例化: 将模块记录转换为一个模块实例,为所有的模块分配内存空间,依照导出、导入语句把模块指向对应的内存地址。
-
运行:运行代码,将内存空间填充
从上面实例化的过程可以看出,ESM
使用实时绑定的模式,导出和导入的模块都指向相同的内存地址,也就是值引用。而 CJS
采用的是值拷贝,即所有导出值都是拷贝值。
import
在顶层使用,require
,可以在代码任意位置调用,并且 CJS 对于 tree shaking 不是很友好
使用
node 提供两个对象, exports 和 require 两个对象
默认导出
// test.js 文件,用作向外暴露接口
module.exports = function(){
console.log("这就是调用的东西")
}
// 其他文件使用 test.js
var ss = require("./test")
ss()
普通导出
// test.js
exports.world = function(){
console.log('hello')
}
// 其他文件进行使用时
const hello = require('./test.js')
hello.world()
require 的查找顺序
-
首先在文件模块缓存区
-
是否是原生模块,如果是,并且在文件缓存区,直接加载,不在缓存区进行加载原生模块,然后放入缓存区。
-
查找文件模块,根据扩展名载入,并且进行缓存
-
当前 node.js 仍然使用的是 CommonJS 规范,所以,不支持
import
导入其他的包。 -
所有代码都运行在模块作用域,不会污染全局作用域。
-
模块可以被多次加载,但只会运行一次,然后将结果进行缓存,之后调用会直接调用缓存的结果,如果不想再调用,必须清除缓存
-
模块的加载顺序和代码顺序相同。
-
CommonJS 规范是服务器规范,一般先下载到本地再调用。
-
读取速度非常,快采用同步的执行方式。
浏览器为何不能兼容 CommonJS
缺少四个环境变量:module/exports/require/global
规范对比
ESM 模块和 CommonJS 模块的差异:
CommonJS
模块输出的是一个值的拷贝(一旦输出一个值,模块内部的变化就影响不到这个值),ES6
模块输出的是值的引用(是动态引用且不会缓存值,模块里的变量绑定其所在的模块,等到脚本真正执行时,再根据这个只读引用到被加载的那个模块里去取值);CommonJS
模块是运行时加载,ES6
模块是编译时输出接口;CommonJS
模块的require()
是同步加载模块,ES6
模块的import
命令是异步加载,有一个独立的模块依赖的解析阶段;
其他规范
Node.js 都可以不看了,这些规范也没有看的必要
AMD
AMD(Asynchronous Module Definition)异步模块定义,规范:(客户端 / 浏览器)
// 声明:
define(function(){
return{
outA:showA,
outB:showB
}
})
// 引入:异步执行
require("moduleA.js",function(moduleA){
// 在模块引入后执行
moduleA.outA()
moduleA.outB()
})
AMD
是异步(asynchronously
)导入模块的(因此得名)- 一开始被提议的时候,
AMD
是为前端而做的(而CJS
是后端) AMD
的语法不如CJS
直观。我认为AMD
和CJS
完全相反
CMD 规范:阿里的一名员工编写的规范,已经不使用了
规范对比
规范 | 相同点 | 不同点 |
---|---|---|
AMD规范 | 都是为了模块化 | 非同步加载模块,允许指定回调函数,为前端而做 |
CommonJS | 同步加载,只有加载完成才能完成后面的操作 |
Require.js 语法遵从 AMD 规范
实现模块化开发的语法,而模块化开发逐渐淘汰了 Require.js,Require.js 在 MVC 模式开发初期开创了辉煌。
注:每一个 .html 都要一个入口文件:管理当前 .html 页面使用的所有 .js 代码 注:除了 require.js 的后缀名,其他文件的后缀名都可以省略
// 客户端 .js 文件遵从 AMD 规范
// add.js
define(function(){
function add(x,y){
return x+y
}
function show(){
console.log('hello world')
}
return {
outAdd:add,
outShow:show
}
})
// main.js
// 引入的路径必须为数组
require(["add"],function(getObj){
var res = getObj.outAdd(1024,2048)
console.log(res)
})
// 实现路径的自动配置
require.config({
path:{
// 假设文件路径在demo下的add中
thePath:'demo/add'
}
})
require(['thePath'],function(){
var res = addObj.outAdd(10,20)
addObj.outShow()
})
子模块使用子模块的方法
// main.js
require(['path/funA','path/funB'],function(){
var res = addObj.outAdd(10,20)
addObj.outShow()
}])
// funA.js
define(function(){
function addA(a,b){
return a+b
}
return {
addA:addA
}
})
// funB.js
define(['path/funA'],function(addA){
add(10,11)
})
UMD
UMD
代表通用模块定义(Universal Module Definition
)。
(function (root, factory) {
if (typeof define === "function" && define.amd) {
define(["jquery", "underscore"], factory);
} else if (typeof exports === "object") {
module.exports = factory(require("jquery"), require("underscore"));
} else {
root.Requester = factory(root.$, root._);
}
}(this, function ($, _) {
// this is where I defined my module implementation
var Requester = { // ... };
return Requester;
}));
- 在前端和后端都适用(“通用”因此得名)
- 与
CJS
或AMD
不同,UMD
更像是一种配置多个模块系统的模式。这里可以找到更多的模式 - 当使用
Rollup/Webpack
之类的打包器时,UMD
通常用作备用模块,通常在ESM
不起作用的情况下用作备用
参考文章
作者 | 链接 |
---|---|
字节跳动ADFE团队 | 深入理解Vite核心原理 |
Gopal | 【面试说】Javascript 中的 CJS, AMD, UMD 和 ESM是什么? |